<?php

// @formatter:off
/**
 * @file WCSearch.php
 *
 * @author Alejandro Dario Simi
 * @date $Date: 2014-05-04 01:33:52 +0000 (Sun, 04 May 2014) $
 *
 * $Id: WCSearch.php 111 2014-05-04 01:33:52Z daemonraco@gmail.com $
 * $URL: http://wcomix.googlecode.com/svn/trunk/includes/search/WCSearch.php $
 */
// @formatter:on
/**
 * @class WCSearch 
 */
class WCSearch extends WCSingleton {
	//
	// Protected class properties.
	public static $ExpirationTime = 10;
	public static $Types = array(
		"IM",
		"DI",
		"RL",
		"CX",
		"CL",
		"TA"
	);
	/**
	 * @var array This is the list of character considered to be words
	 * separator.
	 */
	protected static $_Separators = array(
		"-",
		"/",
		".",
		"[",
		"]",
		"(",
		")",
		"_",
		"&",
		"+",
		"~",
		"?",
		"°",
		"¿",
		"¡",
		"!",
		"'",
		'"',
		":"
	);
	//
	// Protected properties.
	/**
	 * @var WCConnection Pointer to the global connection object.
	 */
	protected $_db = null;
	/**
	 * @var string Shortcut to database tables prefixes.
	 */
	protected $_dbprefix = "";
	/**
	 * @var WCTimer
	 */
	protected $_timer = false;
	/**
	 * @var string[int]
	 */
	protected $_wordsCache = array();
	//
	// Public methods.
	/**
	 * This method allows to know the whole elapsed time used to search, find
	 * and generate results.
	 *
	 * @return int Returns an amount of milliseconds.
	 */
	public function elapsedTime() {
		//
		// Forwarding question to the stop-watch.
		return $this->timer("elapsed");
	}
	/**
	 * 
	 * @param string $searchText
	 * @return WCSearchResult[]
	 */
	public function search($searchText, $exceptionText = false, $types = array(), $limit = false, $offset = false) {
		$result = array();
		//
		// Starting stop-watch for the whole elapsed time on this search.
		$this->startTimer("elapsed");

		$this->startTimer("search");
		$result = $this->realSearch($searchText);
		$this->stopTimer("search");

		if($exceptionText) {
			$this->startTimer("exceptions");
			$exceptions = $this->realSearch($exceptionText);
			$this->stopTimer("exceptions");

			$this->startTimer("filtering");
			$result = array_diff_key($result, $exceptions);
			$this->stopTimer("filtering");
		}

		if(count($types) > 0) {
			foreach($result as $key => $item) {
				if(!in_array($item->type(), $types)) {
					unset($result[$key]);
				}
			}
		}
		if($limit !== false || $offset !== false) {
			$result = array_slice($result, ($offset ? $offset : 0), ($limit ? $limit : null), true);
		}

		//
		// Stopping whole elapsed time stop-watch.
		$this->stopTimer("elapsed");

		return $result;
	}
	/**
	 * This method allows to know a elapsed time of some criteria, by default,
	 * the whole elapsed time.
	 *
	 * @return int Returns an amount of milliseconds.
	 */
	public function timer($code = "elapsed") {
		return $this->_timer->timer($code);
	}
	public function updateIndex($isCron, $spacer) {
		$this->realUpdateIndex($isCron, $spacer);
		$this->cleanDeadIndex($isCron, $spacer);
		$this->optimizeCache($isCron, $spacer);
	}
	//
	// Protected methods.
	protected function cacheSearch($cacheKey, &$data, $words = array()) {
		//
		// Creating the statement to insert a item into cache.
		// @{
		$stmt = null;
		$stmtId = __CLASS__."::".__FUNCTION__;
		if($this->_db->has($stmtId)) {
			$stmt = $this->_db->get($stmtId);
		} else {
			$query = "insert\n";
			$query .= "        into {$this->_dbprefix}search_cache(swc_id, swc_data, swc_words)\n";
			$query .= "        values (:key, :data, :words)\n";

			$stmt = $this->_db->prepare($stmtId, $query);
		}
		// @}		

		$params = array(
			":key" => $cacheKey,
			":data" => gzcompress(serialize($data)),
			":words" => substr(implode(" ", $words), 0, 512)
		);

		$stmt->execute($params);
	}
	protected function cleanCache() {
		//
		// Creating the statement to select a cache item.
		// @{
		$stmt = null;
		$stmtId = __CLASS__."::".__FUNCTION__;
		if($this->_db->has($stmtId)) {
			$stmt = $this->_db->get($stmtId);
		} else {
			$query = "delete from {$this->_dbprefix}search_cache\n";
			$query .= "where       swc_added < (now() - interval ".self::$ExpirationTime." minute)\n";

			$stmt = $this->_db->prepare($stmtId, $query);
		}
		// @}		

		$stmt->execute();
	}
	protected function cleanDeadIndex($isCron, $spacer) {
		//
		// Creating the statement to clean items of no words.
		// @{
		$stmt = null;
		$stmtId = __CLASS__."::".__FUNCTION__."[items]";
		if($this->_db->has($stmtId)) {
			$stmt = $this->_db->get($stmtId);
		} else {
			$query = "delete from {$this->_dbprefix}search_index\n";
			$query .= "where       swi_word not in (\n";
			$query .= "                    select  swd_id\n";
			$query .= "                    from    {$this->_dbprefix}search_words\n";
			$query .= "            )\n";

			$stmt = $this->_db->prepare($stmtId, $query);
		}
		// @}

		if($isCron) {
			echo "{$spacer}Removing items of no words: ";
		}
		if($stmt->execute()) {
			if($isCron) {
				echo WCColorText::ShellGreen($stmt->rowCount())."\n";
			}
		} else {
			if($isCron) {
				echo WCColorText::ShellRed("Failed\n");
			}
		}
		//
		// Creating the statement to clean items of no words.
		// @{
		$stmt = null;
		$stmtId = __CLASS__."::".__FUNCTION__."[words]";
		if($this->_db->has($stmtId)) {
			$stmt = $this->_db->get($stmtId);
		} else {
			$query = "delete from {$this->_dbprefix}search_words\n";
			$query .= "where       swd_id not in (\n";
			$query .= "                    select  swi_word\n";
			$query .= "                    from    {$this->_dbprefix}search_index\n";
			$query .= "            )\n";

			$stmt = $this->_db->prepare($stmtId, $query);
		}
		// @}

		if($isCron) {
			echo "{$spacer}Removing empty words: ";
		}
		if($stmt->execute()) {
			if($isCron) {
				echo WCColorText::ShellGreen($stmt->rowCount())."\n";
			}
		} else {
			if($isCron) {
				echo WCColorText::ShellRed("Failed\n");
			}
		}
	}
	protected function getCachedSearch($cacheKey) {
		$out = array(
			"status" => false,
			"result" => false
		);

		$this->cleanCache();

		//
		// Creating the statement to select a cache item.
		// @{
		$stmt = null;
		$stmtId = __CLASS__."::".__FUNCTION__;
		if($this->_db->has($stmtId)) {
			$stmt = $this->_db->get($stmtId);
		} else {
			$query = "select  swc_data as data\n";
			$query .= "from    {$this->_dbprefix}search_cache\n";
			$query .= "where   swc_id = :key\n";

			$stmt = $this->_db->prepare($stmtId, $query);
		}
		// @}		

		if($stmt->execute(array(":key" => $cacheKey)) && $stmt->rowCount() > 0) {
			$row = $stmt->fetch();

			$out["status"] = true;
			$out["result"] = unserialize(gzuncompress($row["data"]));
		}

		return $out;
	}
	protected function getItems($wordId) {
		$out = array();
		//
		// Creating the statement to select items of a certain word.
		// @{
		$stmt = null;
		$stmtId = __CLASS__."::".__FUNCTION__;
		if($this->_db->has($stmtId)) {
			$stmt = $this->_db->get($stmtId);
		} else {
			$query = "select  *\n";
			$query .= "from    {$this->_dbprefix}search_index\n";
			$query .= "where   swi_word = :word\n";

			$stmt = $this->_db->prepare($stmtId, $query);
		}
		// @}

		if($stmt->execute(array(":word" => $wordId)) && $stmt->rowCount() > 0) {
			$out = $stmt->fetchAll();
		}

		return $out;
	}
	protected function getPatialWordIds($word) {
		$out = array();
		//
		// Creating the statement to select partial word matches.
		// @{
		$stmt = null;
		$stmtId = __CLASS__."::".__FUNCTION__;
		if($this->_db->has($stmtId)) {
			$stmt = $this->_db->get($stmtId);
		} else {
			$query = "select  swd_id\n";
			$query .= "from    {$this->_dbprefix}search_words\n";
			$query .= "where   swd_word like :pattern\n";

			$stmt = $this->_db->prepare($stmtId, $query);
		}
		// @}

		if($stmt->execute(array(":pattern" => "%{$word}%")) && $stmt->rowCount() > 0) {
			foreach($stmt->fetchAll() as $row) {
				$out[] = $row["swd_id"];
			}
		}

		return $out;
	}
	protected function getWordId($word, $isCron = false, $spacer = "", $create = false) {
		$out = false;

		if(isset($this->_wordsCache[$word])) {
			$out = $this->_wordsCache[$word];
		} else {
			$items = WCSearchWordsHolder::I()->itemByName($word);
			$item = array_shift($items);

			if($item) {
				$this->_wordsCache[$word] = $item->id();
				$out = $this->_wordsCache[$word];
			} elseif($create) {
				if($isCron) {
					echo "{$spacer}Creating word '{$word}': ";
				}
				$item = WCSearchWordsHolder::I()->create($word);
				if($item) {
					$this->_wordsCache[$word] = $item->id();
					$out = $this->_wordsCache[$word];
					if($isCron) {
						echo WCColorText::ShellGreen("Done")."\n";
					}
				} else {
					if($isCron) {
						echo WCColorText::ShellRed("Failed")."\n";
					}
				}
			}
		}

		return $out;
	}
	protected function init() {
		//
		// Loading shortcuts.
		// @{
		$this->_db = WCConnection::Instance();
		$this->_dbprefix = $this->_db->prefix();
		$this->_timer = WCTimer::Instance();
		// @}
	}
	protected function optimizeCache($isCron, $spacer) {
		//
		// Creating the statement to select optimize cache table.
		// @{
		$stmt = null;
		$stmtId = __CLASS__."::".__FUNCTION__;
		if($this->_db->has($stmtId)) {
			$stmt = $this->_db->get($stmtId);
		} else {
			$query = "optimize table {$this->_dbprefix}search_cache\n";

			$stmt = $this->_db->prepare($stmtId, $query);
		}
		// @}

		$stmt->execute();
	}
	/**
	 * 
	 * @param string $searchText
	 * @return WCSearchResult[]
	 */
	protected function realSearch($searchText) {
		$out = array();
		//
		// Expanding words to find.
		// @{
		$searchWords = self::StringToPatterns($searchText);
		array_unique($searchWords);
		// @}
		$cacheKey = self::CacheKey($searchWords);
		$cachedSearch = $this->getCachedSearch($cacheKey);
		if($cachedSearch["status"]) {
			$out = $cachedSearch["result"];
		} else {
			//
			// Creating a multicontainer for exact and partial matching
			// words.
			$searchIds = array(
				"exact" => array(),
				"partial" => array()
			);
			//
			// Looking for exact match words.
			foreach($searchWords as $key => $word) {
				$id = $this->getWordId($word);
				if($id) {
					$searchIds["exact"][] = $id;
				}
			}
			//
			// Looking for partial match words.
			foreach($searchWords as $key => $word) {
				foreach($this->getPatialWordIds($word) as $id) {
					$searchIds["partial"][] = $id;
				}
			}
			$searchIds["partial"] = array_unique($searchIds["partial"]);
			$searchIds["partial"] = array_diff($searchIds["partial"], $searchIds["exact"]);
			//
			// Fetching exact words items.
			// @{
			foreach($searchIds["exact"] as $id) {
				foreach($this->getItems($id) as $item) {
					$groupId = $item["swi_group"] ? $item["swi_group"] : false;
					$score = new WCSearchScore(1, 0);

					$result = new WCSearchResult($item["swi_type"], $item["swi_id"], $score, $groupId);
					$key = $result->key();
					if(isset($out[$key])) {
						WCSearchResult::Merge($out[$key], $result);
					} else {
						$out[$key] = $result;
					}
				}
			}
			// @}
			//
			// Fetching partial words items.
			// @{
			foreach($searchIds["partial"] as $id) {
				foreach($this->getItems($id) as $item) {
					$groupId = $item["swi_group"] ? $item["swi_group"] : false;
					$score = new WCSearchScore(0, 1);

					$result = new WCSearchResult($item["swi_type"], $item["swi_id"], $score, $groupId);
					$key = $result->key();
					if(isset($out[$key])) {
						WCSearchResult::Merge($out[$key], $result);
					} else {
						$out[$key] = $result;
					}
				}
			}
			// @}

			uasort($out, "WCSearchResult::Sort");

			$this->cacheSearch($cacheKey, $out, $searchWords);
		}

		return $out;
	}
	protected function realUpdateIndex($isCron, $spacer) {
		global $wcSearchHolders;
		foreach($wcSearchHolders as $holderName) {
			if(method_exists($holderName, "Instance")) {
				eval("\$holder = {$holderName}::Instance();");

				if(!$holder instanceof WCSearchIndexableHolder) {
					continue;
				}

				$type = substr(strtoupper($holder->searchItemsType()), 0, 2);
				$ids = $holder->notIndexedId();

				if(!$ids) {
					continue;
				}

				if($isCron) {
					echo "{$spacer}Indexing '{$type}' items:\n";
				}

				foreach($ids as $id) {
					$item = $holder->item($id);
					if($item && $item->ok()) {
						$words = $item->words();

						if($isCron) {
							echo "{$spacer}\t'{$item->name()}' ({$item->id()}): ".WCColorText::ShellYellow(implode(" ", $words))."\n";
						}

						if($item && $item->ok()) {
							$this->updateItem($type, $item, $isCron, "$spacer\t\t");
						}
					}
				}
			}
		}
	}
	/**
	 * This method allows to start the stop-watch for certain measurement.
	 *
	 * @param string $code Measurement id.
	 */
	protected function startTimer($code) {
		$this->_timer->start($code);
	}
	/**
	 * This method allows to stop the stop-watch of certain measurement.
	 *
	 * @param string $code Measurement id.
	 */
	protected function stopTimer($code) {
		$this->_timer->stop($code);
	}
	protected function updateItem($type, WCSearchIndexable $item, $isCron, $spacer) {
		//
		// Creating the statement to remove old item's words.
		// @{
		$stmt = null;
		$stmtId = __CLASS__."::".__FUNCTION__."[delete]";
		if($this->_db->has($stmtId)) {
			$stmt = $this->_db->get($stmtId);
		} else {
			$query = "delete from    {$this->_dbprefix}search_index\n";
			$query .= "where   swi_type    = :type\n";
			$query .= " and    swi_id      = :id\n";
			$query .= " and    swi_group   = :group\n";

			$stmt = $this->_db->prepare($stmtId, $query);
		}
		// @}
		$params = array(
			":type" => $type,
			":id" => $item->id(),
			":group" => $item instanceof WCRepresentationGroup ? $item->group()->id() : 0
		);

		$stmt->execute($params);

		//
		// Creating the statement to insert item's words.
		// @{
		$stmt = null;
		$stmtId = __CLASS__."::".__FUNCTION__."[insert]";
		if($this->_db->has($stmtId)) {
			$stmt = $this->_db->get($stmtId);
		} else {
			$query = "insert\n";
			$query .= "        into {$this->_dbprefix}search_index(\n";
			$query .= "                swi_word, swi_type, swi_id, swi_group, swi_order)\n";
			$query .= "        values (:word, :type, :id, :group, :order)\n";

			$stmt = $this->_db->prepare($stmtId, $query);
		}
		// @}
		$params[":word"] = 0;
		$params[":order"] = 0;

		$wordsIds = array();
		$params[":order"] = 1;
		foreach($item->words() as $word) {
			$params[":word"] = $this->getWordId($word, $isCron, $spacer, true);
			if($params[":word"]) {
				$stmt->execute($params);
				$params[":order"] ++;
			}
		}

		$item->indexed(true);
	}
	protected function updateItemIndex($type, PDOStatement $stmt, WCHolder $holder, $isCron, $spacer) {
		if($stmt->execute() && $stmt->rowCount() > 0) {
			foreach($stmt->fetchAll() as $row) {
				$item = $holder->item($row["id"]);
				if($item && $item->ok()) {
					$words = $item->words();

					if($isCron) {
						echo "{$spacer}\t'{$item->name()}' ({$item->id()}): ".WCColorText::ShellYellow(implode(" ", $words))."\n";
					}

					if($item && $item->ok()) {
						$this->updateItem($type, $item, $isCron, "$spacer\t\t");
					}
				}
			}
		}
	}
	//
	// Public class methods.
	public static function CacheKey($words) {
		sort($words);
		return md5(implode(".", $words));
	}
	/**
	 * This method allows to take a string and convert it into a list
	 * searchable words.
	 *
	 * @param string String to be converted int a pattern.
	 * @return string[] Returns a list of searchable words.
	 */
	public static function StringToPatterns($str) {
		//
		// Cleaning separators.
		$str = strtolower(str_replace(self::$_Separators, " ", $str));
		//
		// Expanding the string into a list of words and clean empty
		// words.
		$str = array_filter(explode(" ", $str), "WCSearch::WordsFilter");
		//
		// Restarting indexing.
		$str = array_values($str);
		//
		// Returning the list of words.
		return $str;
	}
	/**
	 * This is an evaluation function that detects empty words.
	 *
	 * @param string $value Word to be analyzed.
	 * @return boolean Returns true when the word is not empty.
	 */
	public static function WordsFilter($value) {
		return $value != "";
	}
}

interface WCSearchIndexableHolder {
	/**
	 * This mehtod allows to know which items have to be indexed.
	 *
	 * @return int[] Returns a list of ids
	 */
	public function notIndexedId();
	/**
	 * This mehtod allows to get aan id to distinguish searchable items of
	 * this holder.
	 *
	 * @return string Returns a two characters upper case string with an id
	 * to distinguish searchable items of this holder.
	 */
	public function searchItemsType();
}

/**
 * @interface WCSearchIndexable This interface specifies basic method required
 * by any item that can be indexed by the search engine.
 */
interface WCSearchIndexable {
	/**
	 * Id of the item being indexed.
	 *
	 * @return int Returns an ID.
	 */
	public function id();
	/**
	 * This method allows to know whether an item is indexed or not. Also it
	 * allows to change such status.
	 *
	 * @param boolean $value New status to be set. When absent, this method
	 * works as a getter.
	 * @return boolean Returns true when the object is indexed.
	 */
	public function indexed($value = null);
	/**
	 * This method allows to know if the item has issues.
	 *
	 * @return string Returns item's status.
	 */
	public function ok();
	/**
	 * This method allows to get a list of phrases that must be associated
	 * with an item.
	 *
	 * @return string[] Returns a list of phrases.
	 */
	public function words();
}

class WCSearchScore {
	//
	// Public properties.
	public $exactMatches = 0;
	public $partialMatches = 0;
	//
	// Object methods.
	public function __construct($exact = 0, $partial = 0) {
		$this->exactMatches = $exact;
		$this->partialMatches = $partial;
	}
	public function __toString() {
		return $this->toString();
	}
	//
	// Public methods.
	public function toString() {
		return "{$this->exactMatches}.{$this->partialMatches}";
	}
	//
	// Public class methods.
	public static function Compare(WCSearchScore &$a, WCSearchScore &$b) {
		$out = $b->exactMatches - $a->exactMatches;

		if(!$out) {
			$out = $b->partialMatches - $a->partialMatches;
		}

		return $out;
	}
}

class WCSearchResult {
	//
	// Protected properties.
	protected $_groupId = false;
	protected $_id = false;
	/**
	 * @var WCSearchScore
	 */
	protected $_score = false;
	protected $_type = false;
	//
	// Object methods.
	public function __construct($type, $id, WCSearchScore $score, $groupId = false) {
		$this->_type = $type;
		$this->_id = $id;
		$this->_score = $score;
		$this->_groupId = $groupId;
	}
	//
	// Public methods.
	public function groupId() {
		return $this->_groupId;
	}
	public function id() {
		return $this->_id;
	}
	public function key() {
		return "{$this->_type}{$this->_id}";
	}
	public function score() {
		return $this->_score;
	}
	public function type() {
		return $this->_type;
	}
	//
	// Protected methods.
	//
	// Public class methods.
	public static function Merge(WCSearchResult &$destination, WCSearchResult &$addition) {
		$destination->_score->exactMatches+=$addition->_score->exactMatches;
		$destination->_score->partialMatches+=$addition->_score->partialMatches;
	}
	public static function Sort(WCSearchResult &$a, WCSearchResult &$b) {
		$aScore = $a->score();
		$bScore = $b->score();
		return WCSearchScore::Compare($aScore, $bScore);
	}
}
